luajit 是目前最快的脚本语言之一,不过深入使用就很快会发现,要把这个语言用到像宣称那样高性能,并不是那么容易。实际使用的时候往往会发现,刚开始写的一些小 test case 性能非常好,经常毫秒级就算完,可是代码复杂度一上去了,动辄几十上百毫秒的情况就会出现,性能表现非常飘忽。

为此 luajit 的 mailling list 也是有不少人咨询,作者 mike pall 的一篇比较完整的回答被放在了官方 wiki 上:http://wiki.luajit.org/Numerical-Computing-Performance-Guide

不过原文说了很多怎么做,却基本没有解释为什么。

所以这篇文章不是简单的翻译官方这个优化指南,最主要还是让大家了解 luajit 背后的一些原理,因为原文中只有告诉你怎么做,却没说清楚为什么,导致做了这些优化,到底影响多大,原因是啥,十分模糊。了解背后的原因往往对我们有很大的帮助。

另外,原生 lua、luajit 的 jit 模式(pc 和安卓可用)、luajit 的 interpreter 模式(ios 下只能运行这个),他们执行 lua 的原理是有很大的不同的,也导致一些 lua 优化技巧并不见得是通用的。而这篇文章主要针对 luajit 的 jit 模式。

减少不可预测的分支代码

分支代码就是根据条件会跳转的代码(最典型就是 if..else),那什么是不可预测的分支代码?简单说 :

if 条件 1 then

elseif 条件 2 then

假如条件 1 或者条件 2 其中一方达成的概率非常高(>95%),那我们认为这是可预测的分支代码。

这是被 mike pall 放到第一位的性能优化点(事实上确实应该如此),究其原因是 luajit 使用了 trace compiler 的特性,为了生成的机器码尽可能高效,它会根据代码的运行情况进行一些假设,比如上面的例子如果 luajit 发现,条件 2 的达成概率非常高,那么 luajit 会生成按条件 2 达成执行最快的代码。

有一点可能大家会问,luajit 真的能知道运行过程中的一些情况?

是的

这也是 trace compiler 的特征:先运行字节码,针对热点代码做 profile,了解了可以优化的点后再优化出最高效的机器码。这就是 luajit 目前的做法。

为什么要这样呢?给一个比较好理解的例子:luajit 是动态类型语言,面对一个 a+b,你根本不知道 a 和 b 是什么类型,如果 a+b 只是两个整数相加,那么编译机器码做求和速度自然是飞快的。可是如果你无法确认这点,结果你只能假定它是任意类型,先去动态检查类型(看看到底是两个表,还是两个数值,甚至是其他情况),再跳根据类型做相应的处理,想想都知道比两个整数相加慢了几十倍。

所以 luajit 为了极限级的性能,就会大胆进行假设,如果发现 a+b 就是两个数值相加,就编译出数值求和的机器码。

但是如果某一时刻 a+b 不是数值相加,而是变成了两个表相加呢?这机器码岂不是就导致错误了?因此每次 luajit 做了假设时,都会加上一段守护代码(guard),检查假设是不是对的,如果不对,就会跳转出去,再根据情况,来决定要不要再编译一段新的机器码,来适配新的情况。

这就是为什么你的分支代码一定要可预测,因为如果经常不符合 luajit 假设的东西,就会经常从编译好的机器码中跳出来,甚至会因为好几次假设失败而连跳好几次。所以,luajit 是一个对分支情况极度敏感的语言。

这是 luajit 的第一性能大坑,作者建议可以借助 math.min/max 或者 bitop 来绕过 if else 这样的分支代码。不过实际情况往往更复杂,所有涉及到跳转代码的地方,都是潜在的性能坑。

另外,在 interpreter 模式下(ios 的情况),luajit 就变成了老老实实动态检查动态跳转的执行模式,对分支预测反而并不敏感,并不需要过分注重这方面的优化。

Use FFI data structures.

如果可以,将你的数据结构用 ffi 实现,而不是用 lua table 实现

luajit 的 ffi 是一个常被大家忽略的功能,或者只被当做一个更好用的 c 导出库,但事实上这是一个超级性能利器。

比如要实现 unity 中的 Vector3,分别用 lua table 和用 ffi 实现,我们测试下来,内存占用是 10:1,运算 x+y+z 的耗时也是大概 8:1,优化效率惊人。

代码如下:

local ffi = require ("ffi")

ffi.cdef [[

typedef struct { float x, y, z; } vector3c;

]]

local count = 100000

local function test1 () -- lua table 的代码

local vecs = {}

for i = 1, count do

   vecs [i] = {x=1, y = 2, z = 3}

end

local total = 0

-- gc 后记录下面 for 循环运行时的时间和内存占用,这里省略

for i = 1, count do

total = total + vecs [i].x + vecs [i].y + vecs [i].z

end

end

local function test2 () -- ffi 的代码

local vecs = ffi.new ("vector3c [?]", count)

for i = 1, count do

   vecs [i] = {x=1, y = 2, z = 3}

end

local total = 0

-- gc 后记录下面 for 循环运行时的时间和内存占用,这里省略

for i = 1, count do

   total = total + vecs [i].x + vecs [i].y + vecs [i].z

end

end

为何有这么大的差距?因为 lua table 本质是一个 hash table,在 hash table 访问字段固然是缓慢的并且要存储大量额外的东西。而 ffi 可以做到只分配 xyz 三个 float 的空间就能表示一个 Vector3,自然内存占用要低得多,而且 jit 会利用 ffi 的信息,实现访问 xyz 的时候直接读内存,而不是像 hash table 那样走一次 key hash,性能也高得多。

不幸的是 ffi 只在有 jit 模式的时候才能有很好的运行速度,现在做手游基本都要做 ios,而 ios 下由于只能运行解释模式,ffi 的性能很差(比纯 table 反而更慢),仅仅内存优势得到保留,所以如果要考虑 ios 这样的平台,这个优化点基本可以忽略,或者只在安卓下针对少数核心代码进行优化。

Call C functions only via the FFI.

尽可能用 ffi 来调用 c 函数。

同样的,ffi 也可以用于调用已经 extern c 的 c 函数。大家表面上都以为这样做只是省掉了用 tolua 之类的工具做导出的麻烦,但 ffi 更大的好处,是在于性能上质的提升。

这是因为,使用 ffi 导出 c 函数,你需要提供 c 函数的原型,有了 c 函数的原型信息,luajit 可以知道每个参数的准确类型,返回值的准确类型。了解编译器知识的同学都知道函数调用和返回一般都是用栈来实现的,而要做到这点必须要知道整个参数列表和返回值类型,才能生成出出栈入栈的代码。因此 luajit 在拥有这些信息之后就可以生成机器码,跟 c 编译器一样做到无缝的调用,而不需要像标准的 lua 与 c 交互那样需要调用 pushint 等等函数来传参了。

如果不通过 ffi 调用 c 导出函数,那么因为 luajit 缺乏这个函数的信息,无法生成用于调用 c 函数的 jit 代码,自然会降低性能。而且在 2.1.0 版本之前,这会直接导致 jit 失败,整段相关的代码都无法 jit 化,性能会收到极大的影响。

Use plain 'for i=start,stop,step do …​ end' loops.

实现循环时,最好使用简单的 for i = start, stop, step do 这样的写法,或者使用 ipairs,而尽量避免使用 for k,v in pairs (x) do

首先,直到目前最新的 luajit2.1.0beta2,for k,v in pairs (t) do end 是不支持 jit 的(即无法生成机器码运行)。至于这个坑的存在主要还是因为按 kv 遍历 table 的汇编比较难写,但至少可以知道,目前如果想高效遍历数组或者做 for 循环,直接使用数值做索引是最佳的方法。

其次,这样的写法更利于做循环展开。

循环展开,有利有弊,需要自己去平衡

在早期的 C++ 时代,手工将循环代码展开成顺序代码是一种常见的优化方法,但是后来编译器都集成了一定的循环展开优化能力,代替手工做这种事情。而 luajit 本身也带有这块的优化(可以参考其实现函数 lj_opt_loop),可以对循环进行展开。

不过这个展开是在运行时做的,所以也有利有弊。作者举例,如果在一个两层循环中,内循环的循环次数不够 10 次,这个部分会被尝试展开,但是由于嵌套在外部的大循环,外部大循环可能会导致内部循环多次进入,多次展开,导致展开次数过大,最终 jit 会取消展开。

至于这方面的性能未做深入测试,作者也只是给出了一些比较感性的优化建议(最后来了一句,You may have to experiment a bit),有了解的同学欢迎交流。

Define and call only 'local' (!) functions within a module.

Cache often-used functions from other modules in upvalues.

这两点都可以拿到一起说,即调用任何函数的时候,保证这个函数是 local function,性能会更好,比如:

local ms = math.sin

function test ()

  math.sin (1)

  ms (1)

end

这两行调用 math.sin 有什么区别呢?

事实上 math 是一个表,math.sin 本身就做了一次表查找,key 是 sin,这里消耗了一次。而 math 又是一个全局变量,那还要在全局表中做一次查找(_G [math])

而 local ms 缓存过之后,math.sin 查找就可以省掉了,另外,对于 function 上一层的变量,lua 会有一个 upvalue 对象进行存储,在找 ms 这个变量的时候就只需要在 upvalue 对象内找,查找范围更小更快捷

当然,jit 化后的代码有可能会进一步优化这个过程,但是更好的办法依然是自行 local 缓存

总之,如果某个函数只在本文件内用到,就将其 local,如果是一个全局函数,用之前用 local 缓存一下。 8.Avoid inventing your own dispatch mechanisms. 避免使用你自己实现的分发调用机制,而尽量使用內建的例如 metatable 这样的机制

编程的时候为了结构优雅,常常会引入像消息分发这样的机制,然后在消息来的时候根据我们给消息定义的枚举来调用对应的实现,过去我们也习惯写成:

if opcode == OP_1 then

elesif opcode == OP_2 then

...

但在 luajit 下,更建议将上面实现成 table 或者 metatable

local callbacks = {}

callbacks [OP_1] = function () ... end

callbacks [OP_2] = function () ... end

这是因为表查找和 metatable 查找都是可以参与 jit 优化的,而自行实现的消息分发机制,往往会用到分支代码或者其他更复杂的代码结构,性能上反而不如纯粹的表查找 + jit 优化来得快

Do not try to second-guess the JIT compiler.

无需过多去帮 jit 编译器做手工优化。

作者举了一个例子

z = x [a+b] + y [a+b]

这在 luajit 是性能 ok 的写法,不需要先 local c = a+b 然后 z = x [c] + y

后面的写法其实本身没什么问题,但是 luajit 的另一个坑,即为了提升运算效率,local 变量会尽可能用 cpu 寄存器存储,这样比频繁读内存要快得多(现代 cpu 这可以达到几百倍的差距),但 luajit 在这方面不完善,一旦 local 变量太多,可能会找不到足够的寄存器分配(这个问题在 armv7 上非常明显,在调用层次深的时候,几个变量就会炸掉),然后 jit 会直接放弃编译。这里要说明一点是,很多 local 变量可能只是声明了放在那里没有用,但是 luajit 的编译器不一定能够准确确定这个变量是否可以不再存储,所以适当控制一个函数作用域内的 local 变量的数量是必须的。

当然,不得不说这样写代码还要猜 luajit 的行为确实比较痛苦,一般来说进行 profile 然后对性能热点代码做针对测试和优化基本已经可以。 10.Be careful with aliasing, esp. when using multiple arrays. 变量的别名可能会阻止 jit 优化掉子表达式,尤其是在使用多个数组的时候。

作者举了一个例子

x [i] = a [i] + c [i]; y [i] = a [i] + d [i]

我们可能会认为两 a [i] 是同一个东西,编译器可以优化成

local t = a [i]; x [i] = t + c [i]; y [i] = t + d [i]

实则不然,因为可能会出现,x 和 a 就是同一个表,这样,x [i] = a [i] + c [i] 就改变了 a [i] 的值,那么 y [i] = a [i] + d [i] 就不能再使用之前的 a [i] 的值了

这里跟优化点 9 描述的情形的本质区别是,优化点 9 里头 z/a/b 都是值类型,而这里 x/a 都是引用类型,引用类型就有引用同一个东西的可能(变量别名),因此编译器会放弃这样的优化。

Reduce the number of live temporary variables.

减少存活着的临时变量的数量

原因在 9 中已经说明,即过多的存活着的临时变量可能会耗尽寄存器导致 jit 编译器无法利用寄存器做优化。这里注意 live temporary variables 是指存活的临时变量,假如你提前结束了临时变量的生命周期,编译器还是会知道这一点的。比如:

function foo ()

  do

   local a = "haha"

  end

  print (a)

end

这里 print 是会 print 出 nil,因为 a 离开了 do …​ end 就结束了生命周期,通过这种方式可以避免过多临时变量同时存活。

此外,有一个很常见的陷阱,例如我们实现了一个 Vector3 的类型用于表达立体空间中的矢量,常常会重载他的一些元函数,比如_\_add

Vector3.__add = function (va, vb)

    return Vector3.New (va.x + vb.x, va.y + vb.y, va.z + vb.z)

end

然后我们就会在代码中大肆地使用 a + b + c 来对一堆的 Vector3 做求和运算。

这其实对 luajit 是有很大的隐患的,因为每个 + 都产生了一个新的 Vector3,这将会产生大量的临时变量,且不考虑这里的 gc 压力,光是为这些变量分配寄存器就已经十分容易出问题。

所以这里最好在性能和易用性上进行权衡,每次求和如果是将结果写会到原来的表中,那么压力会小很多,当然代码的易用性和可读性上就可能要牺牲一些。

Do not intersperse expensive or uncompiled operations.

减少使用高消耗或者不支持 jit 的操作

这里要提到一个 luajit 文档中的属于:NYI(not yet implement),意思就是,作者还没有把这个功能做完。。

luajit 快是快在能把代码编译为机器码执行,但是并非所有代码都可以 jit 化,除了前面提到的 for in pairs 外,还有很多这样的东西,最常见的有:

for k, v in pairs (x):主要是 pairs 是无 jit 实现的,尽可能用 ipairs 替代。

print ():这个是非 jit 化的,作者建议用 io.write。

字符串连接符:打日志很容易会写 log ("haha"..x) 这样的方式,然后通过屏蔽 log 的实现来避免消耗。事实上真的可以屏蔽掉吗?然并卵。因为 "haha"..x 这个字符串链接依然会被执行。在 2.0.x 的时候这个代码还不支持 jit,2.1.x 虽然终于支持了,但是多余的连接字符串运算以及内存分配依然发生了,所以想要屏蔽,可以用 log ("haha % s", x) 这样的写法。

table.insert:目前只有从尾部插入才是 jit 实现的,如果从其他地方插入,会跳转到 c 实现。

Last moify: 2023-12-27 10:10:47
Build time:2025-07-18 09:41:42
Powered By asphinx